Skip to content

feat: event-driven governance (sync + async event rules) (#230)#568

Draft
krokoko wants to merge 6 commits into
mainfrom
feat/230-event-governance
Draft

feat: event-driven governance (sync + async event rules) (#230)#568
krokoko wants to merge 6 commits into
mainfrom
feat/230-event-governance

Conversation

@krokoko

@krokoko krokoko commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

You can now govern autonomous agents by meaningful moments ("PR about to open," "cost exceeded $25," "milestone reached") — with rules that either block in real time or react after the fact, that survive container restarts, that can be safely dry-run first, and that are now authored alongside workflows ready for a shared registry.

Closes #230.

What this adds

A unified Event Governance layer. Before this, the only controls were tool-level Cedar policies (gate a bash command / file write). Operators think in moments, not tools — this adds declarative event rules (when X happens → do Y) that fire on lifecycle/milestone/checkpoint/cost events.

Each rule has: on (event), optional when (field matchers + aggregate conditions), action (require_approval / notify / escalate / cancel_task / inject_nudge / observe_only), mode (enforce or observe_only), and evaluation (sync or async).

Two planes, one catalog:

  • Sync — in-agent (Python), at pipeline checkpoints, can block (e.g. pause for approval before the PR is created).
  • Async — the FanOut stream consumer (TypeScript), reacts (notify / escalate / cancel / durable cost & turn ceilings) without blocking the hot path.

Cross-language parity between the two evaluators is locked by shared fixtures tested on both sides. Precedence is explicit: tool Cedar hard-deny always wins; async never overrides a sync deny; every action is deduped by (task_id, rule_id, correlation_id).

Configuration lives on the repo Blueprint (inline rules or a pinned versioned pack). Rules are frozen onto the TaskRecord at submit time, so a task's governance can't shift under it mid-run.

CLI surface (bgagent): rules list (resolved rules for a repo), rules eval --fixture (local dry-run); notifications/approvals flow through the existing watch / pending / approve / deny with an [observe] tag for dry-run rules.

Deferred (follow-ups, not in scope for this PR)

  • Async require_approval on a still-RUNNING task flips the DB status but does not signal the running agent to pause (only the sync gate blocks in-process). The supported path today is post-hoc gates (e.g. pr_created). An in-agent poll would make async approval block a live task — worth a follow-up issue.
  • Async approval cap: the async path doesn't consult the per-task approval_gate_cap the sync path enforces.
  • Parity harness covers on/when/aggregate matching but not the evaluation sync/async split or the policy_decision metadata builders — extending the shared fixtures would close that.
  • EventRule as a discriminated union (on action) would make illegal mode/action combos unrepresentable rather than validated by convention.

Testing

  • CDK: 2297 tests pass, coverage thresholds hold.
  • Agent: full quality suite green (1192 tests), event-governance + parity + gate suites verified.
  • CLI: build + eslint green.
  • Docs Starlight mirror in sync.

Pre-existing, not from this branch: security:sast flags agent/pyproject.toml missing a uv dependency cooldown, and security:secrets flags historical leaks across all commits. Both reproduce on main.

Will turn to ready once live tests are all done

bgagent and others added 6 commits July 8, 2026 17:42
)

Add declarative event rules with sync gates in the agent runtime and async
evaluation in FanOut, plus contracts, API/CLI surfaces, and platform-default
rule pack resolution frozen on task creation.

Co-authored-by: Cursor <cursoragent@cursor.com>
…le-language question (#230)

Aggregate ceiling rules were unreliable:
- turn_count_gte rules never fired — the agent emitted `turn` but every
  evaluator reads `turn_count` (NaN → no match). Agent now also emits the
  cumulative aliases `turn_count` / `cumulative_cost_usd` that the rules read.
- cost/turn aggregates were reconstructed from the current event only, so the
  per-session SDK reset (PR-fix retries, container restart) undercounted them.
  evaluateAsyncEventRules now persists a monotonic high-water mark on the
  TaskRecord (climb-only conditional UpdateItem) and evaluates against it, so
  ceilings survive restarts. Fanout's ad-hoc reconstruction is removed.

Resolves the §10 rule-language open question: declarative field matchers are
permanent; Cedar-on-events is rejected (Cedar is authorization, has no
aggregation, and would add a third Cedar runtime for no gain).

ponytail: high-water is max(session totals), not a cross-session SUM — upgrade
path noted on the TaskRecord fields if PR-fix retries must accrue.
- rethrow retryable infra errors (throttle/5xx) from task load and
  high-water update so the stream retries instead of silently
  under-counting a cost/turn ceiling
- seed the monotonic cost mark from authoritative task.cost_usd so a
  ceiling can trip on a non-cost event and the first eval of an
  already-costed task
- normalize aggregate metadata aliases on both evaluators; drop the
  duplicate cumulative_* / turn_count aliases the progress writer emitted
- default gate_on_event_async ts_module to the real task_state so a
  missing module pauses for approval instead of crashing
- key get-event-rules cache on repo + workflow_ref (rules depend on the
  workflow's eventRulePack)
- add coverage for the new retry/seed paths
Move the event-rules asset tree (schema, packs, parity fixtures) from
contracts/ to agent/event-rules/, alongside agent/workflows/ — one asset
tree, one authoring UX, and the natural source for the future
RegistryService (#246).

The agent runtime never loads these files (it receives resolved
event_rules on the TaskRecord), so only the three file consumers move:
the CDK pack resolver (bundle-time import), the CLI --fixture loader, and
the Python + TS parity/evaluator tests. Dual-plane parity is unchanged —
the CDK-resolved rules remain the single source both planes consume.

Repoints resolver import, CLI fixture path, four test fixture paths,
blueprint schema comment, and EVENT_GOVERNANCE.md (+ Starlight mirror).
Multi-agent review surfaced that the branch hardened three DB read/persist
paths against throttles but left the actual enforcement writes swallowing
them — defeating the ceiling/approval guarantees the branch existed to fix.

- cancel_task and async require_approval now rethrow retryable infra errors
  (throttle/5xx) so the FanOut record retries instead of silently letting a
  task run past its cost ceiling / skip its approval gate; benign conditional
  failures on the approval path are classified and swallowed as INFO
- lift isRetryableInfraError into shared retryable-error.ts (approval module
  consumes it without a circular import)
- release the idempotency marker when an enforce action rethrows, so the
  retried record can re-claim and re-run rather than being deduped away
- observe_only mode no longer fires notify/escalate side effects — gate the
  whole action block on enforce (was only guarding nudge/approval/cancel)
- inject_nudge rethrows retryable errors too
- parseEventRules (TS + Python) logs dropped malformed rules instead of
  silently vanishing a ceiling rule; Python now requires id AND on (no more
  KeyError on missing on)
- add turn_count_gte to the inline BlueprintEventRule.when.aggregate type
- drop severity 'critical' from schema + both type mirrors (the agent
  PolicyEngine only knows low/medium/high)
- remove dead evaluation-filter branch in bgagent rules eval

Tests: async require_approval (enforce + observe), inject_nudge truncation,
idempotency dedup skip, cancel terminal-status guard, retryable-release-and-
rethrow, unknown-pack 422, and a new event-rule-pack-resolver suite covering
inline/pack merge + override. cdk 2297 pass, agent + cli green.
…om 2nd review

- policy_decision emit is now inside the release-guarded try alongside the
  enforce action: a retryable throttle on the audit emit (which rethrows)
  used to leave the idempotency marker claimed but the action un-run, so the
  stream retry skipped the rule entirely — dropping a cost ceiling / approval
  gate silently. Emit + action now share one release-on-failure path.
- cancelTaskByRule swallows a benign ConditionalCheckFailedException (task
  raced to terminal between load and the cancel Update) as INFO instead of
  rethrowing, mirroring the approval path — a CCF no longer parks the record
  in batchItemFailures forever.
- claimIdempotency rethrows retryable infra errors instead of proceeding on an
  unclaimed marker, which risked a double notify/escalate on the eventual retry.

Nits: comment get-event-rules cache as best-effort/per-instance; note that the
CLI rules-eval matcher is a dry-run copy with the parity suites as source of truth.

Tests: emit-failure-release, cancel CCF swallow (no release), claim-throttle
rethrow. cdk 2300 pass, cli green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC: Event-Driven Governance and Actions

1 participant